winbrew_windows\deployment\msi/
builder.rs

1//! Converts resolved MSI rows into WinBrew inventory records.
2//!
3//! This layer assumes the database module has already materialized raw MSI
4//! tables and the directory module has already collapsed the `Directory`
5//! graph into absolute paths. The interesting detail here is that file paths
6//! are computed once and reused, because `File`, `Shortcut`, and `Component`
7//! records all need to agree on the same derived locations.
8//!
9//! Registry handling is intentionally narrow. Only `Root = -1` consults the
10//! install scope; the other root values are passed through as their concrete
11//! registry hives.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use crate::models::install::engine::InstallScope;
17use crate::models::msi_inventory::records::{
18    MsiComponentRecord, MsiFileRecord, MsiRegistryRecord, MsiShortcutRecord,
19};
20
21use super::{
22    ComponentRow, FileRow, RegistryRow, ShortcutRow,
23    path::{normalize_path, normalize_registry_key_path, resolve_reference_path, select_msi_name},
24};
25
26pub(super) fn build_file_paths(
27    file_rows: &[FileRow],
28    component_rows: &HashMap<String, ComponentRow>,
29    directory_paths: &HashMap<String, PathBuf>,
30    install_root: &Path,
31) -> HashMap<String, PathBuf> {
32    // Build a cache of derived file paths keyed by the MSI `File` table key.
33    //
34    // The returned map is used as the canonical source for file records and
35    // as a shared lookup for shortcut and component resolution.
36    let mut file_paths = HashMap::new();
37
38    for file_row in file_rows {
39        file_paths.insert(
40            file_row.file_key.clone(),
41            resolve_file_row_path(file_row, component_rows, directory_paths, install_root),
42        );
43    }
44
45    file_paths
46}
47
48pub(super) fn build_file_records(
49    package_name: &str,
50    file_rows: &[FileRow],
51    file_paths: &HashMap<String, PathBuf>,
52    component_rows: &HashMap<String, ComponentRow>,
53    directory_paths: &HashMap<String, PathBuf>,
54    install_root: &Path,
55) -> Vec<MsiFileRecord> {
56    // Convert MSI `File` rows into storage records.
57    //
58    // If the precomputed file-path cache is missing a key, the code falls
59    // back to row-local resolution instead of dropping the record entirely.
60    file_rows
61        .iter()
62        .map(|file_row| {
63            let path = file_paths
64                .get(&file_row.file_key)
65                .cloned()
66                .unwrap_or_else(|| {
67                    resolve_file_row_path(file_row, component_rows, directory_paths, install_root)
68                });
69
70            MsiFileRecord {
71                package_name: package_name.to_string(),
72                path: path.to_string_lossy().into_owned(),
73                normalized_path: normalize_path(&path),
74                hash_algorithm: None,
75                hash_hex: None,
76                is_config_file: false,
77            }
78        })
79        .collect()
80}
81
82fn resolve_file_row_path(
83    file_row: &FileRow,
84    component_rows: &HashMap<String, ComponentRow>,
85    directory_paths: &HashMap<String, PathBuf>,
86    install_root: &Path,
87) -> PathBuf {
88    let base_dir = component_rows
89        .get(&file_row.component_id)
90        .and_then(|component| directory_paths.get(&component.directory_id))
91        .cloned()
92        .unwrap_or_else(|| install_root.to_path_buf());
93
94    let file_name =
95        select_msi_name(&file_row.file_name).unwrap_or_else(|| file_row.file_name.clone());
96    base_dir.join(file_name)
97}
98
99pub(super) fn build_registry_records(
100    package_name: &str,
101    scope: InstallScope,
102    rows: &[RegistryRow],
103) -> Vec<MsiRegistryRecord> {
104    // Convert MSI `Registry` rows into normalized storage records.
105    //
106    // `Root = -1` is the only case that consults `InstallScope`; the other
107    // roots map directly to concrete hives.
108    rows.iter()
109        .map(|row| MsiRegistryRecord {
110            package_name: package_name.to_string(),
111            hive: registry_root_name(row.root, scope).to_string(),
112            key_path: row.key_path.clone(),
113            normalized_key_path: normalize_registry_key_path(&row.key_path),
114            value_name: row.name.clone().unwrap_or_default(),
115            value_data: row.value.clone(),
116            previous_value: None,
117        })
118        .collect()
119}
120
121fn registry_root_name(root: i32, scope: InstallScope) -> &'static str {
122    match root {
123        0 => "HKCR",
124        1 => "HKCU",
125        2 => "HKLM",
126        3 => "HKU",
127        -1 => match scope {
128            InstallScope::Installed => "HKLM",
129            InstallScope::Provisioned => "HKCU",
130        },
131        _ => "UNKNOWN",
132    }
133}
134
135pub(super) fn build_shortcut_records(
136    package_name: &str,
137    rows: &[ShortcutRow],
138    directory_paths: &HashMap<String, PathBuf>,
139    file_paths: &HashMap<String, PathBuf>,
140    install_root: &Path,
141) -> Vec<MsiShortcutRecord> {
142    // Convert MSI `Shortcut` rows into storage records.
143    //
144    // Shortcut targets are resolved conservatively: when the target is not a
145    // recognizable MSI reference, the target path remains `None` rather than
146    // guessing a filesystem location.
147    rows.iter()
148        .map(|row| {
149            let directory_path = directory_paths
150                .get(&row.directory_id)
151                .cloned()
152                .unwrap_or_else(|| install_root.to_path_buf());
153            let path =
154                directory_path.join(select_msi_name(&row.name).unwrap_or_else(|| row.name.clone()));
155            let target_path = resolve_reference_path(&row.target, directory_paths, file_paths);
156
157            MsiShortcutRecord {
158                package_name: package_name.to_string(),
159                path: path.to_string_lossy().into_owned(),
160                normalized_path: normalize_path(&path),
161                target_path: target_path
162                    .as_ref()
163                    .map(|value| value.to_string_lossy().into_owned()),
164                normalized_target_path: target_path
165                    .as_ref()
166                    .map(|value| normalize_path(value.as_path())),
167            }
168        })
169        .collect()
170}
171
172pub(super) fn build_component_records(
173    package_name: &str,
174    component_rows: &HashMap<String, ComponentRow>,
175    directory_paths: &HashMap<String, PathBuf>,
176    file_paths: &HashMap<String, PathBuf>,
177) -> Vec<MsiComponentRecord> {
178    // Convert MSI `Component` rows into storage records.
179    //
180    // A component key path may resolve through either file references or
181    // directory references, depending on how the MSI package author encoded
182    // the value.
183    component_rows
184        .iter()
185        .map(|(component_id, component)| {
186            let path = component
187                .key_path
188                .as_deref()
189                .and_then(|value| resolve_reference_path(value, directory_paths, file_paths));
190
191            MsiComponentRecord {
192                package_name: package_name.to_string(),
193                component_id: component_id.clone(),
194                path: path
195                    .as_ref()
196                    .map(|value| value.to_string_lossy().into_owned()),
197                normalized_path: path.as_ref().map(|value| normalize_path(value.as_path())),
198            }
199        })
200        .collect()
201}
202
203#[cfg(test)]
204mod tests {
205    use super::registry_root_name;
206    use crate::models::install::engine::InstallScope;
207
208    #[test]
209    fn registry_root_name_uses_scope_for_negative_one() {
210        assert_eq!(registry_root_name(-1, InstallScope::Installed), "HKLM");
211        assert_eq!(registry_root_name(-1, InstallScope::Provisioned), "HKCU");
212    }
213}